iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

https://ithelp.ithome.com.tw/upload/images/20260818/20183337R2aqSnHz5p.png
https://ithelp.ithome.com.tw/upload/images/20260818/20183337y2VIhg6EOw.png
https://ithelp.ithome.com.tw/upload/images/20260818/20183337Qjq63EnDFx.png
https://ithelp.ithome.com.tw/upload/images/20260818/201833372FRRxHBri4.png

今日目的

Day 17 把順序定下來了,今天把第一個掃描 Task 接上去,並且走完整條鏈路:改一個 commit、push、webhook 觸發、Pipeline 跑完

要說明的是一件反直覺的事:掃「現在的檔案」幾乎抓不到東西。 內容被 commit 進去、下一個 commit 改掉,工作目錄乾乾淨淨,但原來那份永遠留在 git 歷史裡。

而選對命令之後還有第二層:git-cloneDEPTH 預設值會讓「掃歷史」只剩一個 commit。這段在第 9 節。


開場對照表

常見的做法 這篇的做法
掃描原始碼就是掃工作目錄 dir 對已刪除的內容完全無效,要用 git
加上 --exit-code 1 才會擋下 CI 預設就是 1,明寫只是自我文件化
--report-path=/dev/null 明確丟掉報告 會讓整個 Task 失敗,見第 6 節
--redact=20 是「加強一點遮蔽」 N 是遮蔽掉的百分比,20 代表只蓋 20%、露出 80%
Task 的 image: 可以寫 Service 位址 不行。拉 image 的是節點上的 kubelet,不在 Pod 網路內
掃描 Task 綠燈就代表沒有洩漏 DEPTH=1 之下只掃了一個 commit
--ignore-gitleaks-allow 就堵住所有靜音手段 它只管 inline 註解,.gitleaksignore 是另一個開關,見 12.2
用手動建的 Pod 驗 Task 的行為 SCC 准入會併入建立者權限,手動 Pod 拿到的 SCC 跟 TaskRun 不同

實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
gitleaks v8.30.1(image 內建 git 2.49.1)
Nexus Repository 3.93.0-06(COMMUNITY)
Gitea 1.27.0
用戶端:Windows 11 Pro + PowerShell 5.1

gitleaks 的 CLI 在 v8.19.0 換過一次:detect / protectgit / dir / stdin 取代。本文的命令只對 8.19.0 以上成立。


1. gitleaks 是什麼

Go 寫的 CLI,MIT 授權,只做一件事:找 hardcoded secret——API key、token、密碼、私鑰。功能單一所以快,快到可以掛 pre-commit hook。

怎麼判斷一段字是不是機密

兩層,缺一不可。

一、regex 比對已知格式。 AWS access key 一定是 AKIA 開頭加 16 碼,GitHub PAT 是 ghp_ 開頭。這類規則誤判率極低,因為格式是官方文件寫死的。

二、Shannon entropy 算亂度。 這是它跟 grep 的差別。your_api_key_here 亂度低,放行;一串隨機的 40 字元亂度高,抓。

輸出裡的 Entropy 欄位就是這個數字:

Secret:   NWNp1RxTIuqaa49aUT6V6bt2O8jLVCkumnp/c2Zz
RuleID:   generic-api-key
Entropy:  4.853056

generic-api-key 這條規則沒有特定前綴可比對,完全靠 entropy 撐。

規則在哪

預設規則在 gitleaks repo 的 config/gitleaks.toml,TOML 格式,內建在 image 裡,執行時不連外。要自訂就在專案根目錄放 .gitleaks.toml

[extend]
useDefault = true      # 保留內建規則

[[rules]]
id = "internal-api-key"
regex = '''(?i)INTERNAL_API_KEY\s*[=:]\s*['"]?([a-zA-Z0-9]{32,})['"]?'''
secretGroup = 1

它抓不到什麼

沒有辨識特徵、亂度又不夠高的東西。 例如自家服務發的 32 碼十六進位 token——沒前綴、entropy 普通,不加規則就滑過去。

這是所有 regex 掃描器的共同上限。反過來,誤判來源也很固定:base64 編碼的設定區塊、文件裡的雜湊值、機器產生的 ID,三者亂度都高。

三個子命令

gitleaks git    掃 git 歷史(每個 commit 的 diff)
gitleaks dir    掃工作目錄的檔案
gitleaks stdin  掃管線輸入

選哪一個決定這道防線有沒有用,第 5 節說明。


這篇分兩半,可以分兩次讀。

快樂路徑(第 2–8 節) 照著做就能跑完,不需要理解為什麼。
技術說明(第 9–13 節) 是為什麼,之後再看不影響你把 Task 接上去。

唯一的例外是第 9 節。 不看那節,你會拿到一個綠燈的掃描 Task,並且以為防線生效了。

快樂路徑

改動範圍只有兩個物件:新增 Task/gitleaks-scan、修改 Pipeline/d15-happy-path

EventListener、TriggerBinding、TriggerTemplate、PVC、Gitea webhook 一個都不動——TriggerTemplate 只指名 Pipeline,不列舉 Task。

2. 共用前置與 image

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kc = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$NEXUS_ROUTE = "nexus-nexus-proxy.apps-crc.testing"

image 走 Day 15 建的 docker-proxy不必新建任何 repository

nexus-nexus-proxy.apps-crc.testing/docker-proxy/zricethezav/gitleaks:latest

latest 目前是 v8.30.1

zricethezav/gitleaks 就是官方發布位置之一——官方 README 的 Docker Hub 安裝指令寫的就是它,ghcr.io/gitleaks/gitleaks 是並列的另一個來源。(zricethezav 是作者 Zachary Rice 的個人帳號,不是舊組織名。Docker Hub 上不存在的是 gitleaks/gitleaks,404。)

「鏡像會不會落後」也不成問題。這顆 image 的 OCI label 是 org.opencontainers.image.source=https://github.com/gitleaks/gitleaksversion=v8.30.1,而它的 created 時間比 GitHub 上 v8.30.1 的發布時間晚 17 秒——同一次 release pipeline 推的。不必另建 ghcr proxy。

位址必須用 Route,不能用 Service,成因在第 10 節。

3. 建 imagePullSecret 並綁到 SA

Nexus 的 anonymous 是關的,拉 image 一定要帶憑證。--docker-server 要跟 image: 裡的主機名逐字相同。

$ns = "nexus-proxy"
$u = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret ci-docker-credentials -n $ns -o jsonpath='{.data.username}')))
$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret ci-docker-credentials -n $ns -o jsonpath='{.data.password}')))

& $OC --kubeconfig $kc create secret docker-registry nexus-route-credentials -n ci `
    --docker-server=$NEXUS_ROUTE --docker-username=$u --docker-password="$p"

& $OC --kubeconfig $kc secrets link pipeline nexus-route-credentials --for=pull -n ci

secrets link 這一步是必要的,不是可選的。 在 Pod spec 裡寫 imagePullSecrets 只對自己建的 Pod 有效;TaskRun 產生的 Pod 走的是 ServiceAccount。少了它第一次跑會得到 TaskRunImagePullFailed

& $OC --kubeconfig $kc get sa pipeline -n ci -o jsonpath='{.imagePullSecrets[*].name}'
# pipeline-dockercfg-xxxxx nexus-route-credentials

ci 裡現在有兩份 Nexus 憑證,用途不同:

Secret server 誰在用

| nexus-route-credentials | Route | kubelet 拉 Task 的 image: |
| nexus-docker-credentials | Service:8081 | Pod 內的 skopeo / buildah 推拉 image |

4. Task 定義

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: gitleaks-scan
  namespace: ci
spec:
  workspaces:
    - name: source
  steps:
    - name: scan
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/zricethezav/gitleaks:latest
      workingDir: $(workspaces.source.path)
      script: |
        #!/bin/sh
        set -e

        echo "=== 工作區裡實際有幾個 commit ==="
        git rev-list --count HEAD

        echo "=== gitleaks ==="
        gitleaks git . \
          --redact \
          --ignore-gitleaks-allow \
          --no-banner

三個決定:

項目 理由
git . 不是 dir . 工作目錄乾淨不代表歷史乾淨——內容刪掉了,diff 還在(第 5 節有實測)
--redact 不帶數字 整串換成 REDACTED不要寫成 --redact=N,N 是遮蔽掉的百分比,20 代表只蓋 20%(12.1 有實測)
--ignore-gitleaks-allow 原始碼裡加一行 # gitleaks:allow 就能讓發現消失,而且 Pipeline 這一側看不出來(12.2 有實測)

三件刻意沒寫的:

  • --exit-code 1 —— 那是預設值。寫了不會錯,但別把它當成防線的一部分。
  • --report-path / --report-format —— 不給就不會產生報告檔。報告檔一定帶 Secret 欄位,是不是明文則完全跟著 --redact 走,所以問題不在「一定是明文」,而在多了一個會落地、會被當成 artifact 保留、遮蔽程度取決於後人有沒有動旗標的檔案(12.1 有三種形態的實測)。(初版曾寫成 --report-path=/dev/null壞的是那個值不是那個旗標,第 6 節整節在講這件事。)
  • git config --global --add safe.directory —— 這台不需要,但不是因為 git 對 root 寬容,是因為 gitleaks 的 image 裡燒了一行 safe.directory=*。換一顆 image 就要自己加(11.1)。

git rev-list --count HEAD 那行不是裝飾:它是唯一能證明「歷史只剩一層」的數字,gitleaks 自己的 commits scanned 證明不了(第 9 節)。

5. 為什麼是 git 不是 dir

用一個實驗 repo:第一個 commit 塞進一段高 entropy 的字串,第二個 commit 刪掉它,工作目錄檔案數 = 0

=== gitleaks dir .(只看工作目錄)===
exit=0
INF scanned ~0 bytes (0) in 2.82ms
INF no leaks found

=== gitleaks git .(掃歷史)===
exit=1
INF 1 commits scanned.
INF scanned ~63 bytes (63 bytes) in 48.1ms
WRN leaks found: 1

同一個 repo、同一組 commit,兩個子命令的結論完全相反,連 exit code 都相反。

dir 沒有錯——它掃了 0 bytes,因為工作目錄真的是空的。問題是「已刪除」不等於「不存在」。

這裡有個數字要先講清楚,第 9 節會用到。 實驗 repo 有 2 個 commit,gitleaks 卻只說 1 commits scanned——因為它只掃 diff 的新增側,第二個 commit 是純刪除,沒有東西可掃。commits scanned 是「掃過的 commit」,不是「repo 的 commit」。 換到兩個 commit 都有新增內容的真實 repo 上,這個數字就等於 2。

還有第二層差異:在真實 repo 上,兩者掃的位元組數差 16 倍。

git 模式   scanned ~1,062,448 bytes (1.06 MB)
dir 模式   scanned ~66,488 bytes (66.49 KB)

repo 有 37 個追蹤檔案,其中 package-lock.json 就佔 1,011,046 bytes——dir 顯然沒掃它。

但**「dir 少掃一個 package-lock.json」解釋不了全部差額**:

git 1,062,448 − package-lock 1,011,046 = 51,402
dir                                    = 66,488    ← 反而多出 15,086

dir 還看到了約 15 KB 是 git 模式沒看到的東西。兩邊掃的檔案集合不是包含關係,成因沒有查證(gitleaks dir . --log-level debug 會列出被跳過的檔案,一次就查得清)。

所以 dir 不只「看不到歷史」,掃描範圍本身也跟 git 對不起來。

6. --report-path=/dev/null 會讓 Task 失敗——壞的是那個值,不是那個旗標

這一節是實際跑起來才發現的,靜態看 YAML 看不出來。

初版 Task 為了「明確把報告丟掉」寫了 --report-path=/dev/null。第一次跑:

FTL Unknown report format:

--report-format 的預設值是空字串,不是 json。但「所以 --report-path 必須搭配 --report-format」是當時的誤讀——官方 README 的 baseline 範例就只給 --report-path gitleaks-report.json,沒給 format。真正的規則是格式判定不出來才會失敗,而 /dev/null 沒有副檔名可判。路徑改成 .json 結尾就不必手動指定。

當時補上 --report-format=json 之後,換一個錯:

FTL could not create Git log cmd  error="open /dev/null: no such file or directory"

/dev/null 明明存在:

crw-rw-rw-  1 root root  1, 3  /dev/null

用一顆乾淨的探測 TaskRun 重測——每種寫法各一個命令、set -x 印出實際 argv、/dev/null 那組故意排最後免得污染前面:

寫法 exit 結果
完全不給 report 旗標 0 no leaks found,不產生報告檔
--report-path=/tmp/b.json不給 format) 0 正常產生報告——副檔名推得出來就不必給 format
--report-format=json --report-path=/tmp/c.json 0 正常產生報告
--report-format=json --report-path=/dev/null 1 could not create Git log cmd error="open /dev/null: no such file or directory"
+ gitleaks git . --redact --no-banner '--report-format=json' '--report-path=/tmp/c.json'
INF no leaks found
C_exit=0
-rw-r--r--  1 root root  3  /tmp/c.json

問題自始至終都在 /dev/null 這個值,不在 --report-path 這個旗標。

初版那張表的第三列(「換成 /tmp/r.json 也是同一個錯」)是探測腳本沒換乾淨造成的假象。那顆 image 的 /bin/sh 是 busybox ash、不支援 PIPESTATUS,三種寫法只能各寫成「重導向到檔案 → echo $?tail」的重複區塊——正是最容易漏改一處的形狀。

/dev/null 為什麼會回 no such file or directory(它明明是個 crw-rw-rw- 的字元裝置)仍然沒有解釋,但範圍已經縮到一個沒有人該去踩的邊角。

這一節留下的教訓因此換了一個,而且比原來的更值錢:

  • /dev/null 不是「安全的丟棄目標」。 想丟掉輸出就別產生它,不要塞一個特殊路徑進去。
  • 兩則錯誤訊息都不指向真正的原因:一則說 report format 未知(真因是 /dev/null 沒有副檔名可判),一則說 /dev/null 找不到(它存在)。
  • 而我從三次紅燈推出的通則本身是錯的。 三個 PipelineRun 確實都失敗,「所以 --report-path 在 Task 裡不能用」卻是假的——真證據,假結論。 推翻它只花了一顆拋棄式 TaskRun。

第 4 節的 Task 仍然不給 report 旗標,但理由換成 12.1 那個:報告會落地,而遮蔽程度取決於後人有沒有動 --redact。不是因為它會讓 Task 失敗。

7. 加進 Pipeline

依 Day 17 的 DAG,gitleaks-scan 直接接在 git-clone 之後。用 JSON patch 附加,不重寫整份 spec:

[{"op":"add","path":"/spec/tasks/-","value":{
  "name":"gitleaks-scan",
  "runAfter":["git-clone"],
  "taskRef":{"name":"gitleaks-scan"},
  "workspaces":[{"name":"source","workspace":"shared-workspace"}]
}}]
& $OC --kubeconfig $kc patch pipeline.v1.tekton.dev d15-happy-path -n ci `
    --type=json --patch-file pipeline-patch.json

JSON patch 一定要走 --patch-file,不要用 -p '[...]'。PowerShell 5.1 會把內層雙引號吃掉,錯誤訊息是 YAML 解析失敗,看起來像 patch 內容寫錯。這是 Day 15 §1.3 第二點的第五次出現。

workspace 的映射照 Day 13 的四層命名:name 是 Task 內部的,workspace 是對回 Pipeline 頂層的。

確認:

& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci `
    -o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"
git-clone <-
workspace-probe <- ["git-clone"]
gitleaks-scan <- ["git-clone"]

TriggerTemplate 不用改。 它的 pipelineRef 只寫 name: d15-happy-path,不列舉 Task。

8. push 一個 commit,走完整條鏈路

推一個真的 commit(改 README),不用 Test Delivery:

git commit -am "docs: note Day 18 gitleaks scan"
git push origin main

Gitea 送 webhook → EventListener 驗簽並過濾 → TriggerTemplate 產生 PipelineRun。

Start-Sleep -Seconds 20
& $OC --kubeconfig $kc get pipelinerun -n ci --sort-by=.metadata.creationTimestamp | Select-Object -Last 1

三個 TaskRun:

TASK              STATUS      START                  END
git-clone         Succeeded   2026-08-15T01:21:35Z   2026-08-15T01:21:43Z
gitleaks-scan     Succeeded   2026-08-15T01:21:43Z   2026-08-15T01:21:49Z
workspace-probe   Succeeded   2026-08-15T01:21:43Z   2026-08-15T01:22:30Z

workspace-probegitleaks-scangit-clone 結束的同一秒01:21:43Z)起跑——Day 17 §2「能並行的並行」在這裡直接看得到。

gitleaks-scan 的 log:

=== 工作區裡實際有幾個 commit ===
1
=== gitleaks ===
INF Unknown SCM platform. Use --platform to include links in findings. host=gitea-http.gitea.svc.cluster.local
INF 1 commits scanned.
INF scanned ~1062506 bytes (1.06 MB) in 72.8ms
INF no leaks found

綠燈。但那個 1 是這篇最重要的數字——repo 有 4 個 commit,工作區只有 1 個。

Unknown SCM platform 是 gitleaks 認不出 Gitea,所以 findings 裡不會有可點擊的連結。不影響偵測。

上面那組輸出的來源要說清楚。 push 確實觸發了 PipelineRun,但頭兩次都撞上第 6 節那個 --report-path 的坑而失敗。這組綠燈的輸出是修好 Task 之後、用 Test Delivery 重新觸發的——main HEAD 沒變,clone 出來的東西完全相同,Pipeline 的行為也一樣,差別只在 payload 的 before/after 欄位,而這條 Pipeline 沒有用到它們。


技術說明

9. 那個 1DEPTH 預設值讓歷史只剩一層

9.1 證據

log 有兩個數字要並排看:

git rev-list --count HEAD  =  1        ← 工作區裡的
repo 實際 commit 數        =  4
gitleaks: 1 commits scanned
           scanned ~1.06 MB

檔案全都在——1.06 MB 掃完了,包含那個一百萬 bytes 的 package-lock.json只有歷史沒有。

為什麼要並排看兩個數字。 第 5 節說過,commits scanned 只算「有新增內容的 commit」。所以「歷史只剩一層」和「四個 commit 但只有一個有新增」在 gitleaks 的輸出上長得一模一樣——單看 1 commits scanned 證明不了 shallow clone,是 git rev-list --count HEAD = 1 把它釘死的。要判斷這道防線掃了多少歷史,這兩個數字缺一不可。

成因是 git-clone 的預設值:

git-clone 的 DEPTH    default = "1"
d15-happy-path        沒有覆寫

DEPTH=1 是 shallow clone,只抓最新一個 commit。工作區的 .git 只有一層,gitleaks git 就只有一個 commit 可掃。

這跟直接用 dir 的差別非常有限。 前面所有設定都對,這道防線仍然只看了最新一次提交。

9.2 要掃完整歷史就得覆寫

    - name: git-clone
      params:
        - name: DEPTH
          value: "0"        # 0 = 不做 shallow clone

這帖藥後來補驗過了,結果是一個很小的數字:commits scanned 從 1 變成 7,而多掃到的內容只有 7,884 bytes。 同一個 repo、同一個時間點,只差 DEPTH 這一個參數,各跑一次:

DEPTH=1 DEPTH=0
git rev-list --count HEAD 1 7
gitleaks commits scanned 1 7
scanned bytes 1,062,103 1,069,987
gitleaks 耗時 59.4ms 95.6ms

7,884 bytes,佔 0.74%。 這個數字把「檔案在、歷史不在」量化了:DEPTH=1 的時候,1.06 MB 的檔案內容一個都沒少,少掉的是那 6 個 commit 的差異——不到 8 KB,卻正是「有人塞了金鑰、下一個 commit 又刪掉」唯一會留下痕跡的地方。這道防線漏掉的從來不是體積,是那 0.74%。

這組數字的來源要說清楚。 補驗是 Day 18 之後才做的,那時 repo 已經從 4 個 commit 長到 7 個。§8 那組輸出仍然是當天 DEPTH=1 的真實紀錄。補驗之後,d15-happy-pathgit-clone 已經實際加上 DEPTH: "0"——這一節開的藥,自己吃了。

至於代價,在這個 repo 上量不出來。 兩次 clone 分別是 25 秒與 10 秒,但 DEPTH=1 那次排在前面、要付 image pull 的錢,這組時間不能拿來比較。能確定的只有:7 個 commit、1 MB 的 repo,深度差別小到看不見。

真正的代價要在歷史長的 repo 上才浮現,隨長度成長。實務上的折衷是設一個有限深度搭配定期全量掃描——但那個數字要有依據,取決於 repo 的 commit 密度與上次全量掃描的時間點。

「沒設定」不等於「沒限制」。 git-clone 沒填 DEPTH 不代表它抓全部,它會套用一個你沒看到的預設值,而那個值正好讓這道防線失效。

10. 為什麼 image: 不能用 Service 位址

Pod 內的 skopeo 可以經 Service 位址拉推 image,那個結論仍然成立。但拉 Task 自己的 image 是另一回事。

寫 Service 位址會得到 ImagePullBackOff

pinging container registry nexus-nexus3.nexus-proxy.svc.cluster.local:8081:
  Get "https://nexus-nexus3.nexus-proxy.svc.cluster.local:8081/v2/":
  dial tcp: lookup nexus-nexus3.nexus-proxy.svc.cluster.local on 192.168.127.1:53: no such host

image:kubelet 在節點上處理,那個動作發生在 Pod 存在之前,不在 Pod 網路裡。兩個問題同時成立:

一、DNS。 節點的 resolver 是 192.168.127.1(主機側),不是 cluster DNS。*.svc.cluster.local 只有 Pod 內解析得到。

二、協定。 CRI-O 預設打 https://,而 Nexus 的 8081 只有 HTTP。

Route 一次解掉兩個:有公開 DNS、有 edge TLS,而 CA 在 Day 15 就裝進節點的 /etc/containers/certs.d/——那份是為 podman 裝的,CRI-O 讀的是同一個位置

誰在拉 執行位置 Service:8081 Route
kubelet(image: 欄位) 節點 不行 可以
Pod 內的 skopeo / buildah Pod 網路內 可以 可以
節點的 podman 節點 不行 可以

之後每一篇都會用到:Task 的 image: 走 Route,Task 裡面的工具推拉 image 走 Service。

11. 兩個只有真的跑才會發現的

11.1 為什麼不需要 safe.directory——image 裡早就寫死了 safe.directory=*

先給答案。 在 step 裡問 git 這個設定是從哪來的,它直接回答:

$ git config --list --show-origin | grep -i safe
file:/root/.gitconfig	safe.directory=*

那份 /root/.gitconfig 只有 22 bytes,是 build 時就燒進 image 的——gitleaks 的 Dockerfile 有一行 RUN git config --global --add safe.directory '*'

[safe]
	directory = *

檢查不是「沒觸發」,是早就被全域放行了。

以下是這件事為什麼值得追,以及為什麼「git 對 root 有例外」是個錯的解釋。


git 2.35 之後,操作「別人擁有的 repo」會被拒絕:

fatal: detected dubious ownership in repository at '/workspace/source'

這在 Tekton 裡很容易踩到——git-clonegitleaks-scan 是兩顆不同的 Pod,寫的人跟讀的人未必是同一個 UID。

這台實測沒觸發:

step 執行身分   uid=0(root) gid=0(root) groups=…,1000860000
HOME            /root
workspace 根    drwxrwsr-x  0      1000860000
.git 的屬主     drwxrwsr-x  65532  1000860000     ← git-clone 寫的
git rev-parse --is-inside-work-tree = true

.git 確實屬於 65532,而 step 跑在 root。原因不是「git 對 root 有例外」——git 的檢查是這樣寫的:

euid = geteuid();
if (euid == ROOT_UID)
{
    if (st.st_uid == ROOT_UID)
        return 1;
    else
        extract_id_from_env("SUDO_UID", &euid);
}
return st.st_uid == euid;

root 只有在檔案本身也屬於 root、或 SUDO_UID 對得上時才放行。.git 屬於 65532、容器裡又沒有 SUDO_UID,照這段程式碼應該要噴才對

真正的原因就是節首那份 /root/.gitconfig

那兩根支柱仍然在承重,只是承的東西換了——它們決定那份設定檔讀不讀得到

  1. gitleaks image 的 USER 是 root--global 在 build 時寫進的是 /root/.gitconfig,得以 root 身分跑、HOME 指向 /root 才讀得到
  2. pipelines-sccrunAsUserRunAsAny → image 宣告的 USER 被沿用;換成強制指定 UID 的 SCC,/root 底下那份就讀不到了

(第 2 點依賴 /root 的權限位元,這一步沒有實際量過。)

所以換掉任何一根——image 改成 non-root、Task 加 securityContext.runAsUser、或換一個 SCC——就要自己加:

git config --global --add safe.directory "$(workspaces.source.path)"

而真正該警戒的還多一種:換一顆沒有那行 safe.directory 的 image。 前三種在 YAML 上看得見,這一種看不見。

這台不加,是因為驗過不需要,不是因為「先不加看看」。

11.2 手動建的 Pod 驗不出 TaskRun 的行為

驗證 11.1 時踩到的獨立坑。

第一次用手動建的 Pod 測,拿到的 SCC 是 anyuid;改用真的 TaskRun 跑,拿到的是 pipelines-scc

原因是 SCC 准入會併入建立者的權限。手動 Pod 以 kubeadmin 建,TaskRun 產生的 Pod 以 pipeline SA 建,兩者能用的 SCC 集合不同。

這次的結論剛好一樣(兩個 SCC 的 runAsUser 都是 RunAsAny),但那是運氣。要驗 Task 在 Pipeline 裡的實際行為,就得跑真的 TaskRun。

12. gitleaks 的三個行為

12.1 --redact 的方向與直覺相反

--redact uint[=100]   redact secrets ... percent value from 0..100 (default 100%)

N 是「遮蔽掉的百分比」,不是「保留的」。 一組 40 字元的字串:

不加 --redact     Secret: NWNp1RxTIuqaa49aUT6V6bt2O8jLVCkumnp/c2Zz
--redact(=100)  Secret: REDACTED
--redact=20       Secret: NWNp1RxTIuqaa49aUT6V6bt2O8jLVCku...

--redact=20 顯示 32 字元,保留了 80%。數字愈小愈危險,看起來像「輕度遮蔽」,實際上是「幾乎沒遮」。

另一件更重要的:console 預設不印機密,JSON 報告一律帶 Secret 欄位。

console 預設(無 -v)    只有 "leaks found: 1",不含內容
console 加 -v            印出 Secret 欄位
JSON 報告                不管有沒有 -v,一律含 Secret 欄位

但「有欄位」不等於「是明文」——報告忠實反映 --redact

不遮蔽       "Secret": "NWNp1RxTIuqaa49aUT6V6bt2O8jLVCkumnp/c2Zz"
--redact     "Secret": "REDACTED"
--redact=20  "Secret": "NWNp1RxTIuqaa49aUT6V6bt2O8jLVCku..."

這反而讓上面那條更嚴重。 --redact=20 印在 console 只是難看,寫進報告檔就是 32 個字元的明文永久落地——而報告正是會被存檔、上傳、當成 artifact 保留的東西

所以第 4 節的 Task 根本不產生報告。這剛好與第 6 節那個坑的修法一致:不給 report 旗標,既避開錯誤,也不必依賴「之後沒有人動到 --redact」這個假設。

12.2 gitleaks:allow 能靜音,Pipeline 看不出來

在原始碼那一行後面加上 # gitleaks:allow,該筆發現就被跳過。

實驗 repo 的第三個 commit 加入一行帶 gitleaks:allow 的相同字串:

不加旗標                   exit=1   leaks found: 1
--ignore-gitleaks-allow    exit=1   leaks found: 2

1 → 2,被靜音的那一筆重新出現。

注意兩次的 exit code 都是 1——從 Pipeline 這一側完全看不出有東西被靜音。如果 repo 裡只有被靜音的那一筆,Task 會直接綠燈通過。

這跟 Day 17 §5「前提二」是同一個形狀:改東西就能繞過防線,不用碰叢集。 差別在那裡改的是 Pipeline 定義,這裡改的是應用程式原始碼——後者門檻更低,任何有 push 權限的人都做得到。

--ignore-gitleaks-allow 是工具內建的對策,但它只管 inline 註解——旗標的說明就是字面上的 ignore gitleaks:allow comments

靜音還有第二條路,這個旗標堵不住: repo 根目錄的 .gitleaksignore(fingerprint 清單),歸另一個旗標 -i, --gitleaks-ignore-path 管,預設值是 .。兩條路各有各的開關,繞過門檻卻一模一樣低——都是改倉庫裡的檔案,有 push 權限就做得到。要一起堵,得把 --gitleaks-ignore-path 指到受控路徑,或把 .gitleaksignore 納入 branch protection 與 code review。

.gitleaksignore 這條本篇沒有實測,只核對過旗標定義。上面 1 → 2 那個實驗只涵蓋 inline 註解那條路。

代價是失去正當的例外機制(測試用的假字串會一直被報)。這裡選擇關掉 inline 那條,因為現階段沒有需要例外的正當案例。

12.3 detect 還在,但不要用

gitleaks detect --help 仍然回 0,只是不列在 Available Commands 裡。官方在 v8.19.0 的公告說「何時移除未定」。

gitleaks detect --source={repo}              →  gitleaks git {repo}
gitleaks detect --no-git --source={repo}     →  gitleaks dir {directory}
gitleaks detect --no-git --pipe              →  gitleaks stdin

新寫的 Task 一律用新命令。網路上大量教學文還停在 detect,照抄會得到一個隨時可能失效的 Task。

13. 一次意外,剛好演完這篇的主題

寫這篇的過程中弄壞了一次 README,形態跟「不小心推了機密」完全一樣。

要改 README 之前得先取原檔,我打了:

.../api/v1/repos/gitea_admin/frontend-nx-mono/raw/branch/main/README.md

/api/v1 這個前綴沒錯,錯的是 branch/main 那一段——那是 web 路徑的寫法。三種形式的實際狀態碼:

/api/v1/repos/{owner}/{repo}/raw/branch/main/README.md   404   ← 我打的
/api/v1/repos/{owner}/{repo}/raw/README.md               200   ← API 的 raw 端點(分支用 ?ref= 指定)
/{owner}/{repo}/raw/branch/main/README.md                200   ← web 路徑

回來的是 80 bytes 的 JSON:

{"message":"not found","url":"https://gitea-gitea.apps-crc.testing/api/swagger"}

狀態碼一直在那裡說 404。 我用的是 curl -s,沒有 -f、也沒有 -w '%{http_code}'——錯誤 body 照樣寫進輸出檔,一聲不吭。沒檢查大小就當成原檔內容、附加一行註解推上去,README 從 4829 bytes 變成 138 bytes。

還原時加了一道檢查:

if [ "$(wc -c < /tmp/orig.md)" -lt 4000 ]; then exit 1; fi

那道檢查應該一開始就有。 這正是 Day 11 §6 慣例一的另一個形態:任何「取回來的東西」都要有一個不該成立的下界當守門員。

兩個可推廣的教訓

一、curl -s 會把錯誤 body 當成內容交給你。 狀態碼明明是 404,但沒有 -f 也沒有 -w '%{http_code}' 的話,那則錯誤 JSON 會安安靜靜寫進輸出檔。取檔案回來,狀態碼和大小至少要看一個。 這跟「查詢回空 body 被讀成沒設定」是同一族的問題——訊號都在,只是沒去讀。

二、工作目錄修好了,歷史還在。

docs: restore README and note Day 18 gitleaks scan   ← 還原
docs: note Day 18 gitleaks scan                      ← 壞掉的那次
chore: nx angular monorepo scaffold for CI
(初始)

repo 現在 4 個 commit,那個 138 bytes 的版本永遠在裡面。要真的清掉得 git reset + force push,不是再推一個 commit 蓋過去。

這就是本篇為什麼用 gitleaks git 而不是 dir 差別在這裡不是理論。

順帶:CI 擋下來不代表沒洩漏

如果那次推上去的是真的金鑰,處理順序是:

1. 作廢那把金鑰                     ← 唯一真正有效的
2. 清 git 歷史(reset + force push)
3. 通知所有 clone 過的人重新 clone    ← 他們本機的 .git 還有
4. 補上 pre-commit hook

第 1 步為什麼優先:從 push 到發現,中間可能有人 clone、fork、CI log 存過、備份掃過。那些副本你控制不了。

所以 CI 這道防線擋下來,只代表「這次沒進到後面的階段」,不代表沒洩漏——金鑰在 push 的那一刻就已經出去了。真正該補的是更前面的 pre-commit hook。


收工前檢查清單

# 1. Task 用的是 git 不是 dir
& $OC --kubeconfig $kc get task gitleaks-scan -n ci -o jsonpath='{.spec.steps[0].script}' |
    Select-String -Pattern "gitleaks (git|dir)"

# 2. 沒有 report 旗標(本篇的選擇:根本不產生報告檔)
& $OC --kubeconfig $kc get task gitleaks-scan -n ci -o jsonpath='{.spec.steps[0].script}' |
    Select-String -Pattern "report-(path|format)"
# 期望:無輸出

# 3. image 是 Route 位址
& $OC --kubeconfig $kc get task gitleaks-scan -n ci -o jsonpath='{.spec.steps[0].image}'

# 4. pull secret 綁在 SA 上(不是只寫在 Pod spec)
& $OC --kubeconfig $kc get sa pipeline -n ci -o jsonpath='{.imagePullSecrets[*].name}'

# 5. Pipeline 的 DAG
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci `
    -o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"

# 6. DEPTH 有沒有覆寫 —— 沒有的話只掃一個 commit
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci -o json | ConvertFrom-Json |
    ForEach-Object { $_.spec.tasks } | Where-Object { $_.name -eq 'git-clone' } |
    Select-Object -ExpandProperty params | Format-Table name, value

# 7. 實際跑出來的 commit 數(Task log 的第一行)
& $OC --kubeconfig $kc logs -n ci -l tekton.dev/pipelineTask=gitleaks-scan --tail=20

第 6 項最容易漏。 前面五項全對,但 DEPTH 沒覆寫的話,這道防線只掃了一個 commit——而 Task 會綠燈。


收尾

三件值得帶走的。

「已刪除」不等於「不存在」。 dir 對已刪除的內容完全無效,git 才掃得到。第 13 節那次 README 意外把這件事演了一遍——工作目錄修好了,那個 138 bytes 的版本仍然在歷史裡。

預設值決定這道防線有沒有作用。 git-cloneDEPTH 預設是 1。真實跑出來的證據是:repo 有 4 個 commit,工作區只有 1 個,1.06 MB 的檔案全部掃完了,歷史卻只剩一層。檔案在,歷史不在。

修法只有一個參數,而且補驗過:DEPTH: "0" 之後 commits scanned 從 1 變 7,多掃到的內容只有 7,884 bytes這道防線原本漏掉的,就是那 0.74%。

看起來合理的旗標「值」可能讓整個 Task 失敗。 --report-path=/dev/null 意圖正確(不讓報告落地),實際上直接讓 Task 跑不起來,而兩則錯誤訊息都不指向真正的原因。

更值得帶走的是後續:我從三次紅燈推出「--report-path 在 Task 裡不能用」,重測之後發現通則是錯的——壞掉的是那個值,不是那個旗標。三個失敗的 PipelineRun 是真證據,卻撐起了一個假結論。這篇一路在講「預設值、旗標、訊息會騙你」,最後連自己的歸納也演了一次。

另外修正兩個常見說法:--exit-code 1 是預設值,不是防護的必要條件;--redact=N 的 N 是遮蔽百分比,數字愈小露出愈多。

明天接第二道掃描。


參考資料

gitleaks

文件 用到的結論
gitleaks/gitleaks 三個子命令(git / dir / stdin);旗標與預設值:--exit-code(default 1)、--redact uint[=100]--ignore-gitleaks-allow只管 gitleaks:allow 註解)、-i, --gitleaks-ignore-path.gitleaksignore,default .)、--baseline-path;baseline 範例只給 --report-path 不給 --report-format;輸出欄位含 Entropy / RuleID / Fingerprint頁首的 Warning:作者已宣告 gitleaks feature complete,不再併入新功能、只出安全修補,重心移往後繼專案 Betterleaks —— 不影響本篇任何結論,但長期選型要算進去
v8.19.0 release detect / protect 棄用,改為三個子命令;三組命令的對照關係
config/gitleaks.toml 預設規則集;[[rules]]regex / entropy / allowlist 結構
zricethezav/gitleaks(Docker Hub) 本篇使用的 image 來源,也是官方 README 給的 Docker Hub 安裝位置(ghcr.io/gitleaks/gitleaks 是並列的另一個來源);image 的 OCI label 指回 github.com/gitleaks/gitleaksversion=v8.30.1;Dockerfile 內含 git config --global --add safe.directory '*',即 11.1 的成因

git / Tekton

文件 用到的結論
git 2.35.2 安全公告(CVE-2022-24765) safe.directorydetected dubious ownership 的由來
git is_path_owned_by_current_uid() root 沒有一般性豁免:euid 為 0 時,只有檔案也屬於 root 才直接放行,否則退回比對 SUDO_UID。11.1 的機制依據
Tekton Tasks workspaces / steps / workingDir 的結構
Tekton Pipelines runAfter 的語意

Gitea

文件 用到的結論
Gitea API 文件 API 的 raw 端點是 /repos/{owner}/{repo}/raw/{filepath}(分支用 ?ref= 指定);raw/branch/{br}/... 是 web 路徑的寫法,打到 /api/v1 底下回 404。第 13 節那次意外的成因

上一篇
Day 17:DAG 的順序就是防線 —— runAfter 的兩條硬邊界
下一篇
Day 19:靜態分析的規則從哪來 —— semgrep 與一份會過期的規則快照
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言